
day 25 結尾把這篇的題目開好了,「Ktor 的 Authentication plugin 怎麼裝、bearer provider 拿到 token 之後要做什麼、驗證失敗的時候那個 401 是誰回的」,還有一句預告,「認證這一層一旦進來,前面那 179 個測試每一個都要帶著身分才打得進去」
用 bearer provider 收 token,authenticate 把 /todos 包起來,token 從 day 17 的設定檔進來、走 day 18 的容器,裝完之後那句預告會兌現到什麼程度,讓那 179 個既有測試自己說,順著它們的反應剛好可以查出認證卡在 pipeline 的哪一段
Principal 這個 marker interface 在 3.x 已經 deprecated,Ktor 自己把它丟掉了authenticate
bearer provider 的設定長什麼樣,驗證函式收什麼、回什麼application.yaml 進來,接上 day 17 的設定跟 day 18 的 DIrealm 什麼時候會被加引號,以及為什麼不加引號的那個版本違反 RFC 9110 的一個 MUST/me 這條路由,跟 call.principal<T>() 為什麼回 nullableoptional = true 放行的到底是什麼先講一件會影響你照抄範例的事,io.ktor.server.auth.Principal 這個 marker interface 在 3.x 已經 deprecated,Principal.kt 這個檔案總共只有 2 個 public 型別,2 個都掛著 @Deprecated
@Deprecated("This interface can be safely removed")
public interface Credential
/**
* A deprecated marker interface that is no longer required for authenticated principals.
*
* Remove this interface from principal classes, or use [Any] when a principal type is still needed.
*/
@Deprecated("Principal is not required anymore. Remove this interface or replace it with Any.")
public interface Principal
寫一個 spike 實作它,data class SpikeUser(val name: String) : Principal,./gradlew compileTestKotlin 就會把那句話原封不動吐回來
w: file:///Users/cash/Downloads/ktor/src/test/kotlin/com/cashwu/todo/AuthSpike.kt:5:42 'interface Principal : Any' is deprecated. Principal is not required anymore. Remove this interface or replace it with Any.
框架自己內建的那個 UserIdPrincipal 現在也不實作任何介面了,SimpleAuth.kt 裡就這麼一行
public data class UserIdPrincipal(val name: String)
取出身分的那個函式,型別參數的上界也只剩 Any,在 Authentication.kt
public inline fun <reified P : Any> ApplicationCall.principal(): P? = principal(null)
P : Any 是「任何非 null 的型別都填得進去」,這個約束等於沒有約束,出處在 Ktor 3.0 的 changelog 那條 Auth: Drop marker interface requirements,連到 YouTrack 的 KTOR-7323,理由逐字是「We have these two empty interfaces that create a small barrier for developers in decoupling their applications from Ktor when implementing their authentication」,以及「It would be handy to remove this type boundary in the auth functions, since they don't serve any purpose now」,那句話裡的 two interfaces 是 Principal 跟 Credential,這篇只碰到前者
翻成白話就是,那個介面除了逼你在自己的 domain model 上 import 一個 Ktor 的型別之外,沒有做任何事,所以這篇的 TodoUser 是一個乾淨的 data class,不繼承任何東西,如果你手上的範例是 3.0 之前寫的,它會有一行 : Principal,照抄過來就會拿到上面那個警告
Ktor 的認證分成 2 個互不相鄰的位置,這 2 個位置沒有分清楚,後面的行為看起來都會很奇怪
第一半在 application 層,install(Authentication) { } 裡面註冊一個或多個 provider,每個 provider 有自己的名字,下面這段先看形狀就好,它最後會落在 src/main/kotlin/com/cashwu/todo/Application.kt,而且 bearer 那一層會被抽成一個擴充函式,寫法在「bearer provider 收到的是什麼」那節
install(Authentication) {
bearer("todo-bearer") {
// 驗證邏輯
}
}
第二半在 routing 層,authenticate("todo-bearer") { } 把一批 route 包起來,這批 route 才會走那個 provider
authenticate 在 AuthenticationInterceptors.kt 裡有 2 個多載
public fun Route.authenticate(
vararg configurations: String? = arrayOf(null),
optional: Boolean = false,
build: Route.() -> Unit
): Route
public fun Route.authenticate(
vararg configurations: String? = arrayOf(null),
strategy: AuthenticationStrategy,
build: Route.() -> Unit
): Route
configurations 是 vararg 的 provider 名字,所以可以一次掛好幾個,差別在第 2 個參數,一個參數收 optional: Boolean,另一個收 strategy: AuthenticationStrategy,而那個 enum 就在同一個檔案裡,只有 3 個值
public enum class AuthenticationStrategy { Optional, FirstSuccessful, Required }
接收 boolean 那個多載的本體只有一句話,把 boolean 翻成 strategy 之後轉呼叫另一個多載
return authenticate(
configurations = configurations,
strategy = if (optional) AuthenticationStrategy.Optional else AuthenticationStrategy.FirstSuccessful,
build = build
)
false 那一邊對到的不是 Required 而是 FirstSuccessful,而 optional 的預設值就是 false,所以 authenticate(TODO_AUTH) 這樣寫,實際跑的策略是 FirstSuccessful,意思是掛上去的 provider 有一個過就算過,這篇只掛一個 provider,所以它跟 Required 沒有差別,真要拿到 Required,也就是「掛上去的每一個都要過」,得走另一個多載明確傳 AuthenticationStrategy.Required,那個參數沒有預設值
兩半之間靠名字對上,名字是字串,字串不會被編譯器檢查,所以打錯會發生什麼事得另外講,那是後面「2 種寫錯的方式」那一節
順帶一提,bearer 只是內建 provider 的其中一種,ktor-server-auth 這個模組裡繼承 AuthenticationProvider 的有這幾個
BasicAuthenticationProvider BasicAuth.kt
BearerAuthenticationProvider BearerAuth.kt
DigestAuthenticationProvider DigestAuth.kt
FormAuthenticationProvider FormAuth.kt
OAuthAuthenticationProvider OAuthProcedure.kt
SessionAuthenticationProvider SessionAuth.kt
差別在身分怎麼跟著請求走,bearer 是每個請求自己在 Authorization 裡帶著憑證來,server 端不需要記得上一個請求發生過什麼事,session 那條要先接上 Sessions plugin,登入的時候建立身分、之後靠 cookie 認人,是另一個模型,這個系列走的是前者,這篇是設定檔裡的固定 token,day 27 換成 JWT
先加相依,build.gradle.kts 的 dependencies 區塊加一行
implementation(ktorLibs.server.auth)
ktorLibs 是 day 02 就在用的 Ktor version catalog,所以這裡不用寫版號,./gradlew dependencies 解出來的 coordinates 是
+--- io.ktor:ktor-server-auth:3.5.2
| \--- io.ktor:ktor-server-auth-jvm:3.5.2
bearer 那個 provider 的設定物件在 BearerAuth.kt 裡,下面是節錄,KDoc 跟 internal 的欄位拿掉之後,能寫的只剩 4 個
public class Config(name: String?, description: String? = null) : AuthenticationProvider.Config(name, description) {
public var realm: String? = null
public fun authenticate(authenticate: suspend ApplicationCall.(BearerTokenCredential) -> Any?) { ... }
public fun authHeader(getAuthHeader: (ApplicationCall) -> HttpAuthHeader?) { ... }
public fun authSchemes(defaultScheme: String = AuthScheme.Bearer, vararg additionalSchemes: String) { ... }
}
4 個東西,realm 是要放進 401 那個 header 的字串,authHeader 可以換掉「從哪裡讀 token」的規則,內部那個預設值是 call.request.parseAuthorizationHeader(),authSchemes 可以換掉認的 scheme 名字,預設值就寫在簽章上是 AuthScheme.Bearer,剩下那個 authenticate 才是主角
它收的是 suspend ApplicationCall.(BearerTokenCredential) -> Any?,ApplicationCall 是 receiver 不是參數,所以在裡面直接寫 call 那一票東西不用再多接一層,回傳型別是 Any?,KDoc 那行 @return a principal or null 說得很清楚,回 null 代表認證失敗
BearerTokenCredential 本身沒有半點魔法,SimpleAuth.kt 裡也是一行
public data class BearerTokenCredential(val token: String)
一個字串,就是 Authorization: Bearer <這裡> 那一段,header 的拆解已經由框架做完了
所以驗證函式要做的事只有一件,拿字串換一個物件,換不出來就回 null
新開一個檔案 src/main/kotlin/com/cashwu/todo/Auth.kt,provider 的設定寫成一個 AuthenticationConfig 的擴充函式放在裡面
const val TODO_AUTH = "todo-bearer"
@Serializable
data class TodoUser(val name: String)
fun AuthenticationConfig.todoAuth(realm: String, authenticator: TokenAuthenticator) {
bearer(TODO_AUTH) {
this.realm = realm
authenticate { credential -> authenticator.authenticate(credential.token) }
}
}
provider 的名字用一個 const val 而不是散在 2 個地方的字面字串,理由就是上一節說的那件事,兩半之間靠名字對上,而字串打錯編譯器不會管
TodoUser 加 @Serializable 是為了後面 /me 直接把它 respond 出去,跟認證本身沒關係
驗證函式現在還缺一個 TokenAuthenticator,它要知道哪些 token 是有效的,而那份名單不該寫死在程式碼裡
先說清楚為什麼會是寫到設定檔,正常的流程是使用者註冊、登入,程式驗過帳號密碼之後簽一個 token 發給他,token 是程式產生的,跟著使用者走,名單這種東西根本不會存在,這個 API 到目前為止只有 todos 一張表,沒有使用者、沒有註冊也沒有登入,發 token 的流程整個不存在,這篇要看的是 Ktor 的 Authentication 架構,也就是「拿到 token 之後由誰驗、驗完的身分放在哪」,發 token 的那一半先用 2 個寫在設定檔的固定字串頂著
src/main/resources/application.yaml 的 todo 節點底下加一段
auth:
realm: '$AUTH_REALM:todo-api'
users:
- name: '$ALICE_NAME:alice'
token: '$ALICE_TOKEN'
- name: '$BOB_NAME:bob'
token: '$BOB_TOKEN'
使用者名稱可以有開發用預設值,token 不行,Bearer token 拿到就能使用對應身分,不能把有效憑證當成一般設定提交,這裡沿用 day 17 的 fail-fast 做法,ALICE_TOKEN 或 BOB_TOKEN 沒有提供就啟動失敗,本機啟動時用環境變數提供,測試則由 Gradle 的 test task 餵 2 個假的進去
src/main/kotlin/com/cashwu/todo/TodoConfig.kt 加 2 個 data class,TodoConfig 多一個欄位
@Serializable
data class TodoConfig(
val requestIdHeader: String,
val responseTimeHeader: String,
val database: DatabaseConfig,
val auth: AuthConfig,
)
@Serializable
data class AuthConfig(
val realm: String,
val users: List<AuthUser>,
)
@Serializable
data class AuthUser(
val name: String,
val token: String,
)
day 17 那套 property("todo").getAs<TodoConfig>() 直接吃得下巢狀的 List<AuthUser>,YAML 的那個序列對到 Kotlin 的 List 不需要任何轉換
不過改到這裡先不要急著往下寫,TodoConfig 多了一個沒有預設值的 auth,測試那邊立刻編譯不過
e: file:///Users/cash/Downloads/ktor/src/test/kotlin/com/cashwu/todo/ConfigurationTest.kt:35:13 No value passed for parameter 'auth'.
src/test/kotlin/com/cashwu/todo/ConfigurationTest.kt 是 day 17 寫的那個測試 the todo node maps onto the data class,裡面組一個 TodoConfig 當期望值,跟真的讀出來的比對,現在少了一個必填參數,整個 test source set 都編不出來,所以在裝 plugin 之前要先把它補回綠燈
補的順序是先解決 token 從哪裡來,application.yaml 那 2 個 $ALICE_TOKEN、$BOB_TOKEN 沒有預設值,測試跑起來就是這個
io.ktor.server.config.ApplicationConfigurationException: Required environment variable "ALICE_TOKEN" not found and no default value is present
這個狀況 day 23 換 PostgreSQL 的時候發生過一次,那次是 DB_PASSWORD 沒有預設值,跟著倒的是 40 幾個 ExceptionInInitializerError,原因是 TestApp.kt 頂層那個 val todoTestConfig 在 class 初始化就把設定讀完,讀不到設定,用到它的測試一起倒,這次一模一樣,只是換成 2 個 token,所以解法照抄那次的,build.gradle.kts 的 tasks.test 已經有那一行了,接在後面加 2 行
tasks.test {
useJUnitPlatform()
environment("DB_PASSWORD", "")
environment("ALICE_TOKEN", "token-alice")
environment("BOB_TOKEN", "token-bob")
這樣測試那邊拿到的是 2 個假的 token,正式啟動仍然要求真的環境變數,application.yaml 裡也不會多出一個能用的預設值
token 有了,ConfigurationTest 那個期望值就只剩補一段,在 expected 的 TodoConfig(...) 裡,接在 database = ... 後面
@Test
fun `the todo node maps onto the data class`() {
val expected = TodoConfig(
requestIdHeader = "X-Request-Id",
responseTimeHeader = "X-Response-Time",
database = DatabaseConfig(
url = "jdbc:postgresql://localhost:5432/todo",
driver = "org.postgresql.Driver",
user = "todo",
password = "",
poolSize = 10,
),
+ auth = AuthConfig(
+ realm = "todo-api",
+ users = listOf(
+ AuthUser("alice", "token-alice"),
+ AuthUser("bob", "token-bob"),
+ ),
),
)
assertEquals(expected, todoConfig("application.yaml"))
}
測試通過,就是前面那句「巢狀的 List<AuthUser> 不用寫任何轉換」的證據
接著是驗證本身,同樣放在 src/main/kotlin/com/cashwu/todo/Auth.kt
class TokenAuthenticator(users: List<AuthUser>) {
init {
require(users.map(AuthUser::token).distinct().size == users.size) {
"Duplicate bearer tokens are not allowed"
}
}
private val entries = users.map { it.token.toByteArray(Charsets.UTF_8) to TodoUser(it.name) }
fun authenticate(token: String): TodoUser? {
val candidate = token.toByteArray(Charsets.UTF_8)
var matched: TodoUser? = null
for ((expected, user) in entries) {
if (MessageDigest.isEqual(expected, candidate)) {
matched = user
}
}
return matched
}
}
3 個地方是刻意寫成這樣的,建構時先拒絕重複 token,避免同一份憑證靜默對到最後一位使用者,MessageDigest.isEqual 不會因內容的第 1 個不同位元組就提早返回,JDK 21 文件寫的是計算時間只取決於第 1 個陣列的長度,迴圈跑完整份名單而不是用 firstOrNull 提早跳出,是為了不讓「命中的是第幾個使用者」從時間上看出來
這不是密碼雜湊,也不會替靜態 token 加上過期或撤銷機制,第 1 個陣列是設定裡的預期 token,所以執行時間仍受每筆預期 token 的長度影響,真正要處理的是下一篇的簽章與期限,不要把 isEqual 當成完整的 token 安全方案
最後把它接進 day 18 的 DI 容器,src/main/kotlin/com/cashwu/todo/Application.kt 的 dependencies { } 裡多一行,TokenAuthenticator 排在最後面,下面的委派屬性也多一行,還有前面說到的 plugin 的部份
fun Application.module() {
dependencies {
// ...
provide<TokenAuthenticator> { TokenAuthenticator(resolve<TodoConfig>().auth.users) }
}
val authenticator: TokenAuthenticator by dependencies
// ...
resolve<TodoConfig>() 那一句就是 day 18 到 day 20 一直在用的形狀,設定物件從容器裡拿出來,餵給下一個需要它的東西,TokenAuthenticator 的建構子收的是 List<AuthUser>,跟 Ktor 沒有任何關係,所以它自己是一個可以單獨測的普通類別,容器只負責把設定裡的那份名單交給它
委派屬性那 3 行也是同一套,by dependencies 在 module 執行的時候把東西解出來,下面 install 跟 routing 2 段都直接用這 3 個名字,不用再寫一次 resolve
同一個 Application.kt,先補 2 個 import
import io.ktor.server.application.install
+import io.ktor.server.auth.Authentication
+import io.ktor.server.auth.authenticate
然後是這篇真正的兩處改動,install 一個 plugin,再用 authenticate 把路由包起來
install(StatusPages) {
todoStatusPages()
}
+ install(Authentication) {
+ todoAuth(config.auth.realm, authenticator)
+ }
routing {
get("/") {
call.respondText("Hello, Ktor!")
}
- todoRoutes(repository)
+ authenticate(TODO_AUTH) {
+ todoRoutes(repository)
+ }
}
get("/") 留在 authenticate 外面,這樣才有一條公開路由可以對照
Authentication 寫在最後一行不代表它最後跑,install 的先後只決定註冊順序,實際的執行順序由每個 plugin 自己掛的 phase 決定,Authentication 掛的是 day 09 那個 internal 的 Validators,排在這些 plugin 的前面,這件事後面「認證卡在 pipeline 的哪一段」那節會用 52 個壞掉的測試量出來
起 server 打打看,2 個 token 從環境變數帶進去
docker compose up -d
ALICE_TOKEN=token-alice BOB_TOKEN=token-bob DB_PASSWORD=todo ./gradlew run
這只適合在本機測試用,把憑證直接打在命令列上,它會留在 shell 的歷史紀錄裡,也會被這個行程往下開的每一個子行程繼承,真的要部署的時候這 2 個值該從 secret manager 或部署平台的 secret 注入,程式這一側不用改,application.yaml 那個 '$ALICE_TOKEN' 讀的還是同一個環境變數,換的是誰把值放進去
不帶任何 Authorization 打 /todos
curl -i -s localhost:8080/todos
HTTP/1.1 401 Unauthorized
X-Request-Id: r-/efydh9n0j
X-Response-Time: 3ms
WWW-Authenticate: Bearer realm=todo-api
Content-Length: 0
帶一個不存在的 token,回應一模一樣
curl -i -s -H "Authorization: Bearer nope" localhost:8080/todos
HTTP/1.1 401 Unauthorized
X-Request-Id: sedp5biymyhn
X-Response-Time: 1ms
WWW-Authenticate: Bearer realm=todo-api
Content-Length: 0
2 個一模一樣這件事,有一半符合規範、一半沒有,RFC 6750 3.1 對「完全沒帶認證資訊」的情況說
If the request lacks any authentication information (e.g., the client
was unaware that authentication is necessary or attempted using an
unsupported authentication method), the resource server SHOULD NOT
include an error code or other error information.
第 1 個回應符合這個 SHOULD NOT,但上一節 3 對「帶了 token 但認證失敗」另外有一條
If the protected resource request included an access token and failed
authentication, the resource server SHOULD include the "error"
attribute to provide the client with the reason why the access
request was declined.
401 這一半 Ktor 做到了,沒做到的是 3 那條,2 種情況都只回一個 WWW-Authenticate: Bearer realm=...,沒有帶 3.1 列的 error="invalid_token",所以 client 分不出來是自己忘了帶還是 token 過期了,要補的話是 authHeader 那一層的事,這篇不做,但要先知道它是這樣
token 對的那次
curl -i -s -H "Authorization: Bearer token-alice" localhost:8080/todos
HTTP/1.1 200 OK
X-Request-Id: x2z/g8-op5j8
X-Response-Time: 6ms
Content-Length: 245
Content-Type: application/json
[{"id":1,"title":"買牛奶","done":true,"created_at":"2026-08-27T08:00:00Z"},{"id":2,"title":"繳電費","done":false,"created_at":"2026-08-28T09:30:00Z"},{"id":3,"title":"寫 day 05 的文章","done":false,"created_at":"2026-08-29T21:15:00Z"}]
公開路由不受影響
curl -i -s localhost:8080/
HTTP/1.1 200 OK
X-Request-Id: lhckxj+dm-k+
X-Response-Time: 0ms
Content-Length: 12
Content-Type: text/plain; charset=UTF-8
Hello, Ktor!
接著把 header 的各種變形打一輪,這段是 bash 語法,照 day 21 的做法存成專案根目錄的 auth-headers.sh 再用 bash 跑
for h in "bearer token-alice" "BEARER token-alice" "Basic dG9rZW4tYWxpY2U=" \
"token-alice" "Bearer" "Bearer token-alice"; do
printf -- '--- Authorization: %-23s → ' "$h"
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: $h" localhost:8080/todos
done
bash auth-headers.sh
這一輪實測的 status code
--- Authorization: bearer token-alice → 200
--- Authorization: BEARER token-alice → 200
--- Authorization: Basic dG9rZW4tYWxpY2U= → 401
--- Authorization: token-alice → 401
--- Authorization: Bearer → 401
--- Authorization: Bearer token-alice → 200
最後一行是 Bearer 後面 2 個空白,這 6 行不是隨便決定的,每一條都對得上 RFC,scheme 大小寫那 3 行是 RFC 9110 11.1 定的,auth-scheme 是一個 case-insensitive 的 token,2 個空白也合法那一行是 RFC 6750 2.1 的語法定義
b64token = 1*( ALPHA / DIGIT /
"-" / "." / "_" / "~" / "+" / "/" ) *"="
credentials = "Bearer" 1*SP b64token
1*SP 就是 1 個以上的空白,順帶一提 - 也在 b64token 允許的字元裡,所以 token-alice 這種帶連字號的 token 是合法的
Basic dG9rZW4tYWxpY2U= 那一行是重點,那串 base64 解開就是 token-alice,內容完全正確,但 scheme 不是 Bearer,所以 provider 根本不看它,認證比對的第 1 步不是 token,是 scheme
上面那些回應裡的 WWW-Authenticate: Bearer realm=todo-api 沒有引號,把 realm 換成一個帶空白的字串再重新啟動
AUTH_REALM="todo api" ALICE_TOKEN=token-alice BOB_TOKEN=token-bob \
DB_PASSWORD=todo ./gradlew run
curl -i -s localhost:8080/todos
HTTP/1.1 401 Unauthorized
X-Request-Id: 0r9q6/nz/y4n
X-Response-Time: 3ms
WWW-Authenticate: Bearer realm="todo api"
Content-Length: 0
引號出現了,也就是 Ktor 只在需要的時候才加引號,值本身如果是合法的 token 就不加
這個做法對大部分 client 都不會有問題,但它違反了 RFC 9110 11.5 最後一段的一個 MUST
For historical reasons, a sender MUST only generate the quoted-string
syntax. Recipients might have to support both token and quoted-
string syntax for maximum interoperability with existing clients that
have been accepting both notations for a long time.
a sender MUST only generate the quoted-string syntax,也就是不管值長什麼樣,送出去的一律要有引號,Bearer realm=todo-api 這個版本不符合
接收端為了相容既有實作,本來就得接受 2 種格式,但發送端仍應產生 quoted-string,這是 Ktor 3.5.2 的序列化行為,不是我們的設定寫錯,原始碼裡看得到它是怎麼決定的,BearerAuth.kt 兩處都呼叫 HttpAuthHeader.bearerAuthChallenge(defaultScheme, realm),而那個函式在 HttpAuthHeader.kt
public fun bearerAuthChallenge(scheme: String, realm: String? = null): HttpAuthHeader = Parameterized(
authScheme = scheme,
parameters = if (realm == null) emptyMap() else mapOf(Parameters.Realm to realm)
)
Parameterized 的第 3 個參數沒有給,吃的是預設值 HeaderValueEncoding.QUOTED_WHEN_REQUIRED,而 render 的時候按這個 enum 分岔
private fun String.encode(encoding: HeaderValueEncoding) = when (encoding) {
HeaderValueEncoding.QUOTED_WHEN_REQUIRED -> escapeIfNeeded()
HeaderValueEncoding.QUOTED_ALWAYS -> quote()
HeaderValueEncoding.URI_ENCODE -> encodeURLParameter()
}
RFC 9110 要的那個行為就是 QUOTED_ALWAYS,它一直都在,只是 bearerAuthChallenge 沒選它,而 bearer provider 沒有給我們插手挑戰的地方,realm 那個欄位只能給字串、選不了 encoding,所以這篇的測試就照量到的樣子一字不差寫下來,Bearer realm=todo-api 是什麼就斷言什麼,這是一筆掛著的債,下一篇才有工具還
回頭看第 1 個 401,最後一行是 Content-Length: 0,這不是巧合,Ktor 回那個 challenge 用的型別本身就沒有 body
public class UnauthorizedResponse(public vararg val challenges: HttpAuthHeader) : OutgoingContent.NoContent() {
override val status: HttpStatusCode
get() = HttpStatusCode.Unauthorized
override val headers: Headers
get() = if (challenges.isNotEmpty()) {
Headers.build {
challenges.forEach { challenge -> append(HttpHeaders.WWWAuthenticate, challenge.render()) }
}
} else {
Headers.Empty
}
}
UnauthorizedResponse.kt 整個檔案就這樣,繼承的是 OutgoingContent.NoContent,status 寫死 401,headers 只組 WWW-Authenticate,從頭到尾沒有一個地方碰得到 body
沒有 body 這件事在測試那邊的後果更具體,TestApplicationTest 裡那個用 ContentNegotiation 解 JSON 的測試,失敗訊息長這樣
--- TestApplicationTest.a client with ContentNegotiation decodes and encodes()
io.ktor.client.call.NoTransformationFoundException: Expected response body of the type 'class kotlin.collections.List' but was 'class io.ktor.utils.io.SourceByteReadChannel'
In response from `http://localhost/todos`
Response status `401 Unauthorized`
Response header `ContentType: null`
Request header `Accept: application/json`
You can read how to resolve NoTransformationFoundException at FAQ:
https://ktor.io/docs/faq.html#no-transformation-found-exception
Response header ContentType: null,連 content type 都沒有
問題是 day 15 花了一整篇把這個 API 的錯誤回應統一成一個形狀,{"status":...,"message":...,"details":[]},一個沒有 body 的 401 等於在那個形狀上開了一個洞,client 拿到 400、404、500 都能用同一段程式碼解,唯獨 401 不行
補法還是 day 15 用的 StatusPages,但 401 多一個限制,認證失敗的 status 與 challenge 已經確定,不能再讓請求的 Accept 把它改成 406
在 src/main/kotlin/com/cashwu/todo/ErrorHandling.kt 的 todoStatusPages() 裡,接在既有那幾個 status(...) 後面加一段,先把 ErrorResponse 序列化,再用固定為 application/json 的 respondText 回應
fun StatusPagesConfig.todoStatusPages() {
// ...
status(HttpStatusCode.Unauthorized) { call, status ->
val body = Json.encodeToString(
ErrorResponse(status.value, "請帶著有效的 token 再來"),
)
call.respondText(
text = body,
contentType = ContentType.Application.Json,
status = status,
)
}
}
這裡沒有直接呼叫 day 15 的 respondError,那個 helper 會把 ErrorResponse 交給 ContentNegotiation,遇到 Accept: text/xml 時可能改回 406,respondText 送的是已經完成的 OutgoingContent,不再做格式協商
重新 curl 一次沒帶 token 的請求
curl -i -s localhost:8080/todos
HTTP/1.1 401 Unauthorized
X-Request-Id: vusjm+eob1s9
X-Response-Time: 3ms
WWW-Authenticate: Bearer realm=todo-api
Content-Length: 58
Content-Type: application/json
{"status":401,"message":"請帶著有效的 token 再來"}
2 件事同時成立,WWW-Authenticate 還在,沒有被 StatusPages 洗掉,body 換成了 day 15 那個形狀,即使請求送 Accept: text/xml,結果仍是 401 與同一個 challenge,不會被 ContentNegotiation 改成 406
那個 58 是 JSON 字串的 UTF-8 byte 數,中文那幾個字各佔 3 個 byte,details 沒有出現是因為這裡用的是裸的 Json,預設不編碼有預設值的欄位,ContentNegotiation 那個 encodeDefaults = true 的 Json 管不到 respondText
Relix 那篇對這個 header 的說法可以拿來對照,「401 只寫 status code 是不夠的,RFC 7235 規定 401 必須告訴 client 該用哪種方式認證,少了這個 header,client 只知道被拒絕,不知道下一步要做什麼」,Ktor 這邊的情況剛好倒過來,header 一開始就在,缺的是 body
前面那些都是單獨看認證這條路,現在把 Authentication 裝上去之後的整包測試跑一次,測試那邊只有前面那個 ConfigurationTest 的期望值,除了那一個地方,測試其它地方一個字都沒動,測試 50 多個有問題
TodoRoutesTest 那 23 個測試裡活下來 2 個,名字逐字是
put todos without an id responds method not allowed()
todos path with trailing slash responds not found()
1 個回 405、1 個回 404,需要先了解目前認證掛在那裡
AuthenticationInterceptors.kt 開頭那個 AuthenticationHook,攔截的位置寫得很清楚
internal object AuthenticationHook : Hook<suspend (ApplicationCall) -> Unit> {
override fun install(
pipeline: ApplicationCallPipeline,
handler: suspend (ApplicationCall) -> Unit
) {
@Suppress("INVISIBLE_REFERENCE")
pipeline.intercept(ApplicationCallPipeline.Validators) { handler(call) }
}
}
那個 @Suppress("INVISIBLE_REFERENCE") 順便解釋了 day 09 為什麼查得到卻用不到,Validators 是 internal,Ktor 自己要用還得先把可見性檢查關掉
Validators 就是 day 09 埋的那個伏筆,那篇原文是「3.5 版的原始碼裡其實還躲了第 6 個 phase,一個 internal 的 Validators,夾在 Plugins 和 Call 之間,註解說它是給 authentication、rate limiting、CORS 這類守門 plugin 用的,它沒有開放給使用者,先知道有這個位置就好,day 11 裝 CORS 和 RateLimit 的時候會再碰到這一帶」
這一篇碰到了
但這只解釋了一半,真正判斷「這條 route 要不要認證」的東西不在 application 的 pipeline 上,而是一個 route-scoped plugin
public val AuthenticationInterceptors: RouteScopedPlugin<RouteAuthenticationConfig> = createRouteScopedPlugin(
"AuthenticationInterceptors",
::RouteAuthenticationConfig
) {
route-scoped 的意思是它掛在 route 節點自己的 pipeline 上,請求要先在 routing 裡匹配到某一條 route,那條 route 的 pipeline 才會跑,認證才會執行
那 2 個沒壞的測試就是這樣活下來的,PUT /todos 沒有 handler,routing 直接回 405,GET /todos/ 帶尾斜線匹配不到任何 route,直接回 404,兩者都在匹配階段結束,authenticate 底下的 route 一個都沒被選中
失敗訊息的形狀幾乎都一樣,期望的 status 變成 401
--- TodoRoutesTest.todos path responds all todos as json()
org.opentest4j.AssertionFailedError: expected: <200 OK> but was: <401 Unauthorized>
--- RequestValidationTest.malformed json never reaches validation()
org.opentest4j.AssertionFailedError: expected: <400 Bad Request> but was: <401 Unauthorized>
--- TodoRoutesTest.post todos with non json content type responds unsupported media type()
org.opentest4j.AssertionFailedError: expected: <415 Unsupported Media Type> but was: <401 Unauthorized>
中間那個 400 跟下面那個 415 才是資訊量最大的,malformed JSON 現在連 validation 都到不了,content type 不對現在連 negotiation 都到不了,因為那 2 件事都發生在 route 的 handler 裡面,而認證排在 handler 之前,這跟 405、404 那 2 個對照著看就完整了,認證的位置在「匹配到 route 之後、handler 之前」
會打 /todos 的測試 class 裡,有 3 個完全沒壞,CorsTest、RateLimitTest 和 RequestTimingTest,但它們沒壞的理由跟 405、404 那 2 個不一樣,這 3 個 class 是自己在測試裡 application { } 拼一個小 module,根本沒有走 todoApplication(),所以跟認證無關
至於認證前面的那幾層,log 裡看得到
07:02:37.002 INFO [vusjm+eob1s9] Application -- 401 GET /todos 16ms
07:02:37.089 INFO [zqhgk879f3el] Application -- 201 POST /todos 50ms
401 那一行一樣有 call id、一樣被記錄、上面的 curl 輸出裡也一樣有 X-Response-Time,day 09 說過 CallId 的位置是 Setup,「典型住戶是 CallId,在最前面替每個請求發一個識別碼」,day 16 查出來的是「CallLogging 的 2 個主要動作分別在 Setup phase 跟 send pipeline,只有 MDC 模式會在 Monitoring 跟 Call 前面各插一個自己的 phase」,發號碼在 Setup、計時起算也在 Setup、記那一行在 send pipeline,3 件事都不在 Validators 這一段,所以被認證擋掉的請求在 log 上跟正常請求一樣完整
前面說過兩半之間靠字串對上,字串打錯會怎樣,用 day 24 講過的 startApplication() 觸發 module 就看得到,不用送任何請求
provider 名字打錯,authenticate("nope")
java.lang.IllegalArgumentException: Authentication configuration with the name nope was not found. Make sure that you install Authentication plugin before you use it in Routing
完全忘記 install(Authentication),直接寫 authenticate { }
io.ktor.server.application.MissingApplicationPluginException: Application plugin AuthenticationHolder is not installed
2 個都是在 module 執行的那一刻丟出來的,正式環境用 EngineMain 起的時候 module 在啟動階段就跑完,所以這 2 種錯是啟動失敗,server 根本起不來
這個設計拿掉了一整類問題,一個沒有被任何測試覆蓋到的路由,如果它的 provider 名字打錯了,你不會等到有人打那條路由才發現
認證成功之後,驗證函式回的那個物件放在 call 上,用 call.principal<T>() 取出來,同樣放在 src/main/kotlin/com/cashwu/todo/Auth.kt,加一條 /me
fun Route.meRoute() {
get("/me") {
val user = call.principal<TodoUser>()
?: throw ApiException(HttpStatusCode.Unauthorized, "這個請求沒有身分")
call.respond(user)
}
}
ApiException 是 day 15 定的那個帶 status code 的例外,StatusPages 接住之後照 ErrorResponse 的形狀回出去
掛回 Application.kt 的 authenticate 裡面,跟 todoRoutes 一起受同一個 provider 保護
routing {
authenticate(TODO_AUTH) {
+ meRoute()
todoRoutes(repository)
}
}
那個 ?: 看起來多餘,這條路由在 authenticate(TODO_AUTH) 底下,只掛一個 provider,跑到 handler 就代表那個 provider 過了,怎麼可能拿不到,但 call.principal<TodoUser>() 的回傳型別確實是 nullable,而且拿不掉,原因在最前面那個 P : Any,型別參數是呼叫端自己給的,框架這一側實際做的事在 Principal.kt 那個 internal 的 CombinedPrincipal 裡
@Suppress("UNCHECKED_CAST")
fun <T : Any> get(provider: String?, klass: KClass<T>): T? {
return principals
.firstOrNull { (name, principal) ->
if (provider != null) {
name == provider && klass.isInstance(principal)
} else {
klass.isInstance(principal)
}
}?.second as? T
}
klass.isInstance(principal) 配上最後那個 as? T,你寫 principal<TodoUser>() 只是在告訴編譯器「我猜裡面是這個型別」,如果 provider 實際放進去的是別的東西,firstOrNull 就找不到,框架擔保不了呼叫端寫對,所以只能回 null
實際打一下
curl -i -s -H "Authorization: Bearer token-alice" localhost:8080/me
HTTP/1.1 200 OK
X-Request-Id: eb=jn4jtqbr4
X-Response-Time: 1ms
Content-Length: 16
Content-Type: application/json
{"name":"alice"}
換 bob 的 token
curl -s -H "Authorization: Bearer token-bob" localhost:8080/me
{"name":"bob"}
帶 token 的 POST 也照舊,day 20 到 day 23 建起來的那條路一點都沒變
curl -i -s -X POST localhost:8080/todos \
-H "Authorization: Bearer token-alice" \
-H 'Content-Type: application/json' \
-d '{"title":"倒垃圾"}'
HTTP/1.1 201 Created
X-Request-Id: zqhgk879f3el
X-Response-Time: 48ms
Content-Length: 84
Content-Type: application/json
{"id":4,"title":"倒垃圾","done":false,"created_at":"2026-09-03T23:02:37.048982Z"}
authenticate(name, optional = true) 這個參數的名字很容易被讀成「認證可有可無」,但它放行的範圍比那個窄
驗證的方式是另外拼一個最小的 application,只有一個 /whoami,拿不到身分就回 anonymous
這個 helper 放在新增的 src/test/kotlin/com/cashwu/todo/AuthenticationTest.kt 裡
private fun ApplicationTestBuilder.whoamiApplication(
realm: String = "todo-api",
optional: Boolean = false,
) {
configure(overrides = { put("ktor.application.modules.size", "0") })
application {
install(Authentication) {
todoAuth(realm, TokenAuthenticator(todoTestConfig.auth.users))
}
routing {
authenticate(TODO_AUTH, optional = optional) {
get("/whoami") {
call.respondText(call.principal<TodoUser>()?.name ?: "anonymous")
}
}
}
}
}
configure 是 testApplication 自己的 API,day 24 把它的本體拆開看過,overrides 蓋得過 application.yaml,ktor.application.modules.size 設成 0 是 day 18 那一招,設定檔裡的 module() 就不會跟著載進來,這樣這個 application 只有上面寫的那幾行,todoTestConfig 仍從 day 17 的 YAML 設定建立,token 那兩格是前面 tasks.test 餵進來的假值,不需要正式環境的憑證
這個檔案的 import 有一個容易踩的地方,install 跟 routing 要的是 io.ktor.server.application.install 與 io.ktor.server.routing.routing 這 2 個掛在 Application 上的 extension,而 ApplicationTestBuilder 自己也有同名的成員函式,不用 import 就看得到,少了那 2 個 import,application { } 裡面就挑不到可用的候選,編譯器的訊息是
e: AuthenticationTest.kt:24:13 'fun <P : Pipeline<*, PipelineCall>, B : Any, F : Any> install(plugin: Plugin<P, B, F>, configure: B.() -> Unit = ...): Unit' cannot be called in this context with an implicit receiver. Use an explicit receiver if necessary.
照著訊息加 this@whoamiApplication. 也會過,TestApplicationBuilder.install 的本體是 applicationModules.add { install(plugin, configure) },把這次 install 包成另一個 module 排進去,最後仍裝在同一個 application 上,補 import 少繞這一圈
然後在同一個 AuthenticationTest.kt 裡,2 個測試並排
@Test
fun `an optional provider lets an anonymous request through`() = testApplication {
whoamiApplication(optional = true)
val response = client.get("/whoami")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("anonymous", response.bodyAsText())
}
@Test
fun `an optional provider still rejects a token it does not know`() = testApplication {
whoamiApplication(optional = true)
val response = bearerClient("nope").get("/whoami")
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
2 個合起來說的是同一件事,optional 放行的是「完全沒有帶認證資訊」,不是「認證失敗」,帶了一個不認識的 token 照樣 401
這個區分在做「登入了顯示個人化內容、沒登入顯示公開版」這種路由的時候很重要,一個壞掉的 token 不會被當成匿名使用者悄悄放進去,它一樣會被擋下來
bearerClient 是下一節才會出現的 helper,先知道它回的是一個預設帶指定 token 的 client
現在處理 day 25 預告的那件事,/todos 這個路徑在既有測試裡,散在很多檔案,一個一個加 header 是最糟的做法
真正的接縫只有 2 個,todoApplication() 跟 postgresApplication(),所有 API 測試都從這 2 個 helper 開始,做法是讓它們回傳一個已經帶好 token 的 client
src/test/kotlin/com/cashwu/todo/TestApp.kt 先補 import 跟一個常數
import io.ktor.client.HttpClient
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get
val REQUEST_ID_HEADER: String = todoTestConfig.requestIdHeader
+val TEST_TOKEN: String = todoTestConfig.auth.users.first().token
val FIXED_NOW: Instant = Instant.parse("2026-10-10T12:00:00Z")
TEST_TOKEN 沒有自己再寫一份,直接從 todoTestConfig 拿第 1 位使用者的 token,application 讀到的名單跟 client 帶出去的那一串是同一個來源,兩邊不會各自漂移,假的 token 由 tasks.test 的環境變數提供,正式啟動仍要求環境變數,不會因為測試方便又把有效預設值放回 application.yaml
然後新增一個 bearerClient fun,以及讓 todoApplication() 從回傳 Unit 改成回傳 bearerClient() (HttpClient)
fun ApplicationTestBuilder.bearerClient(token: String = TEST_TOKEN): HttpClient =
createClient {
defaultRequest { bearerAuth(token) }
}
fun ApplicationTestBuilder.todoApplication(
developmentMode: Boolean = false,
clock: Clock? = null,
repository: TodoRepository? = null,
): HttpClient {
// ...
return bearerClient()
}
src/test/kotlin/com/cashwu/todo/PostgresSupport.kt 一樣回傳 bearerClient() (HttpClient)
fun ApplicationTestBuilder.postgresApplication(): HttpClient {
// ...
return bearerClient()
}
呼叫端的地方把 HttpClient 接出來,用它來呼叫
下面是 src/test/kotlin/com/cashwu/todo/TodoRoutesTest.kt 的樣子,其他檔案形狀一樣
@Test
fun `todos path responds all todos as json`() = testApplication {
// todoApplication()
val client = todoApplication()
val response = client.get("/todos")
// ...
}
底下那些 client.get、client.post、client.put、client.delete 一個字都不用改,這個區域變數 client 蓋掉的是 ApplicationTestBuilder 自己那個沒帶 token 的 client 屬性,Kotlin 的名稱解析裡區域變數優先於隱含 receiver 的成員,./gradlew compileTestKotlin --rerun-tasks 跑出來沒有任何跟遮蔽有關的警告
另外有 4 個地方一行解決不了,第 1 個是 TestApplicationTest 那個自己 createClient 的測試,token 要加在它自己身上
@Test
fun `a client with ContentNegotiation decodes a typed body`() = testApplication {
- val jsonClient = createClient { install(ContentNegotiation) { json() } }
+ val jsonClient = createClient {
+ install(ContentNegotiation) { json() }
+ defaultRequest { bearerAuth(TEST_TOKEN) }
+ }
// ...
}
第 2 個是同一個 class 的 an external service inherits the configured modules,它同時要有帶 token 跟不帶 token 的 client,這裡不能遮蔽,只能另外取名
@Test
fun `an external service inherits the configured modules`() = testApplication {
configure(overrides = h2Database)
externalServices {
hosts("https://api.example.com") {
routing { get("/rate") { call.respondText("""{"rate":31.5}""") } }
}
}
+ val authed = bearerClient()
assertEquals("""{"rate":31.5}""", client.get("https://api.example.com/rate").bodyAsText())
- assertEquals(HttpStatusCode.OK, client.get("https://api.example.com/todos").status)
+ assertEquals(HttpStatusCode.OK, authed.get("https://api.example.com/todos").status)
}
第 3 個是 a provided repository keeps the database out of the test,它自己拼 module 沒有走 todoApplication()
@Test
fun `a provided repository keeps the database out of the test`() = testApplication {
configure(overrides = {
h2Database()
put("todo.database.url", "jdbc:nowhere:boom")
put("ktor.application.modules.size", "0")
})
application { dependencies.provide<TodoRepository> { FakeTodoRepository(emptyList()) } }
application { module() }
+ val client = bearerClient()
// ...
}
第 4 個是 PostgresRoutesTest 那個跨 2 個 application 的測試,第 2 個 testApplication block 是直接 configure 的
@Test
fun `what one application writes the next one still sees`() {
testApplication {
// ...
}
testApplication {
configure(overrides = postgresDatabase)
serverConfig { developmentMode = false }
+ val client = bearerClient()
assertEquals(4, client.todoCount())
assertContains(client.get("/todos").bodyAsText(), "上一個 application 寫的")
}
}
這 4 個地方要跟上面那些 val client = 一起改完再跑測試,只改一半的話,看到的失敗不一定是 401,而是 TodoRoutesTest 那種資料筆數對不上的斷言,為什麼會這樣留到這一節最後那個小節講
改完再跑 ./gradlew test,測試全部通過
補到剩 8 個沒過的時候,TodoRoutesTest 冒出來的失敗不是 401,是這種
--- TodoRoutesTest.todos path responds all todos as json()
org.opentest4j.AssertionFailedError: expected: <[{"id":1,"title":"買牛奶",...}]> but was: <[{"id":1,"title":"買豆漿","done":false,...},{"id":3,...},{"id":4,"title":"倒垃圾",...},{"id":5,...},{"id":6,...},{"id":7,...}]>
種子資料被別人動過,而且多了好幾筆,單獨跑 --tests "com.cashwu.todo.TodoRoutesTest" 是通過的,2 個 class 一起跑才會壞
bisect 出來的兇手是 TestApplicationTest,它那 3 個還沒補 token 的測試在斷言那一行就中斷了,jdbc:h2:mem:todo 這個 in-memory database 沒有被乾淨地清掉,於是原樣留給下一個 class 用,把那 3 個補完,這個現象跟著消失
要說的是這個,一個沒補完的認證改動,症狀不一定是 401,可能是別的 class 看起來莫名其妙的資料汙染,改到一半看到看不懂的失敗,先問還有哪裡沒改完,比先去追那個資料庫划算得多
Relix day 23 一篇做完認證跟授權,這篇只做認證那半,授權留到 day 28,那篇的分層說法拿到這裡完全成立,「認證是把 token 換成一個身份,授權是拿這個身份去比對路由要求的權限,分開做,兩邊都會變得比較好測」
第 1 個對照點是驗證函式的簽名,手刻那版是「驗證函式的簽名是 (String) -> Principal?,給 token 字串,回 Principal 表示成功,回 null 表示失敗,不丟 exception,null 就夠了」,Ktor 這邊是 suspend (ApplicationCall, BearerTokenCredential) -> Any?,多了 call、多了 suspend,但 null 代表失敗這件事是同一個設計
有意思的是回傳型別那一格,手刻那版特地定義了一個 marker interface,「Principal 只有一行,是個 marker interface,一樣放進 Auth.kt」,接著「具體的身份由應用層自己定義」,理由是「因為每個應用的使用者模型不一樣,有些只需要 userId,有些需要角色清單、權限列表、組織 ID,框架提供介面,應用填入內容」,這個推理沒有錯,而 Ktor 走完同一條路之後的結論是那個介面連留都不用留,直接退化成 Any?,KTOR-7323 那句「they don't serve any purpose now」講的就是這件事
第 2 個對照點是 header 的拆解,Relix 花了一節寫 parseBearer 的 7 個測試,空白、空字串、大小寫各有一個,這篇的那 6 行 curl 驗的是同一批情況,只是拆解的是 Ktor 的 HttpAuthHeader parser,行為一致,手刻的價值在於知道那 7 種情況存在,用框架的價值在於不用自己維護
第 3 個對照點是名字,Relix 的目標 API 裡 authenticate { } 沒有名字
app.routing {
get("/public") { ok("hello") }
authenticate {
get("/me") { ok(principal<UserPrincipal>().name) }
}
authorize("admin") {
get("/admin") { ok("admin panel") }
}
}
Ktor 的 provider 一定有一個 key,不給名字就是 null 那個 default provider,多這一層換來的是同一個 application 裡可以並存好幾套認證,代價是名字打錯要到 module 執行才會爆,也就是上面那兩則訊息
第 4 個對照點是拿不到身分的時候怎麼辦,Relix 那版分得很細,「context 是空的表示認證沒發生過,這是 client 的問題,型別對不上表示 middleware 塞進去的東西跟 handler 要的不是同一種,這是框架或設定的問題,不該讓 client 看到 401 然後以為換個 token 就會好」,Ktor 的 call.principal<T>() 2 種情況都回 null,分不出來,決定權在呼叫端,這篇的 meRoute() 選的是回 401,而按照 Relix 那個分類,型別對不上其實應該回 500
最後一個對照點是「這條要認證」怎麼標記,Relix 在 Route 上加了一個 requiresAuth 欄位,還因此把 17 處位置參數改成具名參數,Ktor 用的是 route-scoped plugin,掛在 route 節點自己的 pipeline 上,不用動 Route 的資料結構
補到前面新增的那個 src/test/kotlin/com/cashwu/todo/AuthenticationTest.kt 測試,除了上面已經出現過的 optional 那 2 個之外,其餘的分成幾組
第 1 組是挑戰本身,status 跟 header 放一個測試、body 另一個,再加一個「不認識的 token 走的是同一條路」,另有正向對照,有效 token 打得進 /todos,公開的 / 不受影響
@Test
fun `a request without a token is answered with a challenge`() = testApplication {
todoApplication()
val response = client.get("/todos")
assertEquals(HttpStatusCode.Unauthorized, response.status)
assertEquals("Bearer realm=todo-api", response.headers[HttpHeaders.WWWAuthenticate])
}
@Test
fun `the challenge body keeps the error shape from day 15`() = testApplication {
todoApplication()
val response = client.get("/todos")
assertEquals(
"""{"status":401,"message":"請帶著有效的 token 再來"}""",
response.bodyAsText(),
)
}
@Test
fun `an unknown token is rejected the same way as no token`() = testApplication {
todoApplication()
val response = bearerClient("nope").get("/todos")
assertEquals(HttpStatusCode.Unauthorized, response.status)
assertEquals("Bearer realm=todo-api", response.headers[HttpHeaders.WWWAuthenticate])
}
前 2 個測試裡的 client 沒有被 val client = 蓋掉,用的是 ApplicationTestBuilder 那個不帶 token 的 client,這正是這裡要的
第 3 個測試把前面那個 RFC 6750 3 的缺口寫成斷言,記錄的是現況,不是我們想要的樣子
第 2 組是 header 的變形,把那 6 行 curl 搬成 4 個測試,scheme 大小寫、換一個 scheme 帶同一串文字、token 前面多空白、只有 Bearer 沒有 token
@Test
fun `another scheme carrying the same text is not a bearer token`() = testApplication {
todoApplication()
val response = client.get("/todos") {
header(HttpHeaders.Authorization, "Basic $TEST_TOKEN")
}
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
第 3 組是身分,2 個 token 各自換出自己的使用者,以及 /me 回的 JSON
@Test
fun `each token resolves to its own user`() = testApplication {
todoApplication()
assertEquals("""{"name":"alice"}""", bearerClient("token-alice").get("/me").bodyAsText())
assertEquals("""{"name":"bob"}""", bearerClient("token-bob").get("/me").bodyAsText())
}
這裡 2 串 token 是直接把設定裡的字串寫進測試的,TEST_TOKEN 只指得到第 1 位使用者,要同時拿 2 個身分做對照就只能自己寫,這件事下一篇換成 JWT 之後會變得不一樣
第 4 組是前面那個 405 跟 404,把「認證在 route 匹配之後才跑」寫成斷言
@Test
fun `a path that matches no route is a 404 and not a challenge`() = testApplication {
todoApplication()
val response = client.get("/todos/")
assertEquals(HttpStatusCode.NotFound, response.status)
assertEquals(null, response.headers[HttpHeaders.WWWAuthenticate])
}
@Test
fun `a method with no handler is answered before authentication runs`() = testApplication {
todoApplication()
val response = client.put("/todos")
assertEquals(HttpStatusCode.MethodNotAllowed, response.status)
assertEquals(null, response.headers[HttpHeaders.WWWAuthenticate])
}
第 2 個斷言比第 1 個重要,沒有 WWW-Authenticate 這個 header 才是「認證沒有跑過」的直接證據,只看 status code 的話,一個回 404 的認證失敗看起來也一樣
剩下一個是 realm 的引號,把前面那節量到的行為寫成斷言
@Test
fun `a realm that needs quoting gets quoted`() = testApplication {
whoamiApplication(realm = "todo api")
val response = client.get("/whoami")
assertEquals("""Bearer realm="todo api"""", response.headers[HttpHeaders.WWWAuthenticate])
}
只測得到「需要的時候會加」這一半,RFC 9110 要的「不需要也一律要加」現在還不成立,寫了就是失敗,這個測試驗的是目前量到的行為,等 Ktor 改成一律加引號,它會第 1 個失敗
Ktor 3.x 的 principal 不用實作任何 marker interface,認證分成兩半,application 層裝具名 provider,routing 層用 authenticate 把路由包起來。bearer 的驗證函式回物件代表成功、回 null 代表失敗,optional = true 只放行完全沒帶認證資訊的請求,錯誤 token 照樣 401
token 名單寫在 application.yaml,但有效 token 沒有預設值,正式啟動一定要環境變數,測試由 Gradle 的 test task 餵假值,TokenAuthenticator 拒絕重複 token,比對用 MessageDigest.isEqual
Ktor 原本的 401 沒有 body,用 StatusPages 補成 day 15 的 JSON 形狀時要先序列化,再用固定 application/json 的 respondText 回應,Accept: text/xml 才不會把它改掉。2 筆債留在原地,3.5.2 對不需要引號的 realm 輸出沒有引號的 token 格式,違反 RFC 9110 §11.5 的一個 MUST,401 也少了 RFC 6750 的 error 參數,bearer provider 沒開讓我們接手挑戰的鉤子,只能照量到的樣子寫進測試
下一篇講 JWT,這篇的 token 仍是由環境設定提供的固定字串,沒有過期、沒有簽章,換一位使用者也要改設定並重新啟動 server,JWT 要處理的就是這些限制,簽章讓 token 證明發行者,過期時間讓它自動失效,refresh token 處理過期之後怎麼續,架構這一半已經搭好了,bearer 換成 jwt,驗證函式從查名單換成驗簽章
同步刊登於 Blog
圖片來源:AI 產生